Skip to content

fix(client): default organizations.invite role to 'member' so the shorter call works - #17270

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-16582-invite-role-default-member
Sep 10, 2026
Merged

fix(client): default organizations.invite role to 'member' so the shorter call works#17270
os-project-manager merged 4 commits into
mainfrom
claude/issue-16582-invite-role-default-member

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #16582

Clause-②: no

organizations.invite declares role? as optional, and better-auth 1.7.2's body schema for POST /organization/invite-member makes role required. The SDK forwarded the caller's object verbatim, so the documented-looking minimal call was refused before it reached any ObjectStack code. Omitting role now sends 'member'.

No published type moves. role stays optional; the declaration is untouched. The other self-consistent repair — declaring role required — is a narrowing of a published request type and was weighed and rejected on the card; case ⑤ of the new suite is a compile-time pin that reddens if anyone takes it later.

Why 'member', and the objection answered out loud

Defaulting a role means implicitly choosing a permission level for the caller. Three things make 'member' the answer rather than a guess:

  • The sibling already does it. organizations.invitations.resend has always substituted role ?? 'member' over the same vendor endpoint. That asymmetry is the whole reason the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. Aligning them is 「① 缩小特例」的直接兑现——同一家族一套行为.
  • It is the floor of a closed vocabulary. ADR-0108 D1 fixes the membership roles at owner, admin, delegated_admin, member; orgRoleGrade starts at GRADE_MEMBER and rises only for owner/admin. The default cannot confer more reach than the caller asked for.
  • Nothing is conferred at invite time. The row lands status: 'pending'; the invitee must still accept, and an admin can change the role first.

I do not judge implicit role assignment unacceptable here, so the route was not switched.

Driven — before and after, on a real wire

Real AuthManager (better-auth 1.7.2, organization plugin, teams enabled — 9 team endpoints present) over a real SqlDriver (better-sqlite3), through the real ObjectStackClient:

BEFORE   POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"}
AFTER    POST /organization/invite-member -> 200 {"role":"member","status":"pending","teamId":null, ...}

Both runs also drove the two controls that must not move: an explicit role: 'member' (200 before and after) and invitations.resend (200 before and after).

The committed suite packages/client/src/organization-invite-role-default.test.ts re-drives this rather than mocking it — real AuthManager over a real ObjectQL on a real SqliteWasmDriver, only the socket stood in for. Cases ④a–c additionally hold the request bytes to full-string equality, which is the half the drive structurally cannot see.

Caller census — the stop condition, and it did not trip

The card and triage made this the first task, with a positive control, because 「一个未经证明的搜索面给出的零是仪器的零,不是读数」.

surface reading positive control that fired
in-repo no live caller of the two-argument form packages/client/src/index.ts resend's own call into invite is found by the same query
Console (objectui @ HEAD 348725a and @ pin 53ded82) .invite(0 hits; Console uses its own useAuth().inviteMember, which always sends a role (ORG_ROLE_MEMBER default) inviteMember → 55 hits at the pin; @objectstack/client → 91 files at HEAD

The three in-repo hits that are not live callers: resend (sends a role), return-type-precision.test.ts (type-only expectTypeOf, never issues a request), and a packages/client/CHANGELOG.md example — which this change makes true. ⇒ Latent trap, not a path broken today; the p3 grading stands.

Ablation — prediction written first, observed as predicted

Predicted before any leg ran: reverting to the pre-fix body turns the suite RED at 4 failed / 2 passed, failing exactly ①, ③, ④a, ④c and passing ② and ④b (both name a role explicitly, so their bytes are unchanged).

Observed: Tests 4 failed | 2 passed (6)× ①, × ③, × ④a, × ④c. Prediction held.

Mutation proven on disk before the run (fixed spelling 1 → 0, pre-fix spelling 16 → 17, blob 264f459c…9f399ef7…). Restore proven after: git checkout HEAD -- ABSOLUTE_PATH, blob back to 264f459c… = HEAD's blob, git diff HEAD empty, counts back to 1/16, git status --porcelain clean. No dist/ preflight applies — the suite imports the subject as ./index, so the resolution path is source, not the package's exports.

Verification

Anchored at the final head (post-merge with origin/main).

  • Gate family — derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final tree: 59 families, all green, reconciled with --ran (59 derived / 59 run / 0 NOT-MEASURED / 0 UNRUN). Three of them (check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt) first answered PREREQUISITE NOT MET / OOM against an unbuilt tree — ⛔ recorded as NOT MEASURED, then rebuilt and re-run to a real green.
  • Packagepnpm --filter @objectstack/client test: 41 files / 492 tests passed. typecheck: green, and check:test-typecheck compiles the test layer, so case ⑤'s type pin is really evaluated.
  • Lint — the whole population, not a narrowing: eslint . --no-inline-config --format json over 6449 files, 0 errors, 0 warnings, exit 0.
  • Docs driftaffected-docs.mjs lists 0 rows. The three blind spots checked by hand: the only text this change makes newly TRUE is the generated packages/client/CHANGELOG.md example; the prose-class sweep across content/docs and docs/ (which the tool never walks) finds 5 files naming the surface, and every one of them spells role explicitly, so none is stale in either direction.

Scope

Request shape only. No response body, no declared return type, no published response contract. ⛔ PR #16576 / card #14314 untouched — it binds return types and its pin's invite({ email }) is a type-only assertion that never issues a request. ⛔ Not merged with #15447 (same seam, opposite failure mode: there the vendor silently strips, here it loudly requires).

packages/client/src/index.ts is a measured hard-serial hot spot. Re-swept at claim time and again at the end of the run: 23 open PRs, paged to the end, each read against its own merge base (the one 100-file page fully paged to 207) — zero touch this file. Control: the same method detects it on #17237 and #16694.

验收备注

  • invitations.resend still spells its own role: invitation.role ?? 'member'. It is now redundant, and it is left alone deliberately: removing it is polish on a hard-serial file and changes no behaviour. Worth folding into whatever next edits that method.
  • invitations.resend declares teamId?: string | null and never forwards it — invite has no teamId member, so a caller's team placement is silently dropped. Noted here rather than filed; see the report comment on the card.

Generated by Claude Code

better-auth 1.7.2's body schema for POST /organization/invite-member makes
`role` REQUIRED, while the SDK declared it optional and forwarded the caller's
object verbatim. The shorter call the declaration advertises —
`invite({ email, organizationId })` — was therefore refused with
`400 [body.role] Invalid input` before it reached any ObjectStack code.

`role` stays optional; omitting it now sends 'member', which is exactly what
the sibling `invitations.resend` has always substituted over the same vendor
endpoint. One family, one behaviour, and no published type moves.

Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-authored-by: Claude <noreply@anthropic.com>
…mission

A caller writing `invite({ email, role: maybeRole })` with an empty variable
must reach the wire as `role: 'member'`, not as no role at all. This is the
case that separates `{ ...req, role: req.role ?? 'member' }` from the
`{ role: 'member', ...req }` spelling, which agrees with it on every status the
driven cases can observe.

Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d03c3c96d63a407e52605aed4e9a1df7395e244epackageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 10, 2026 01:59

Copy link
Copy Markdown
Collaborator

ACCEPT — armed for the queue

Dispatching seat (domain:cli execution PM, seat post #6024). Head ef5c3e58c765c94b42185748340b29dc36291ee3, ready flip then auto-merge, in that order.

CI: 33 check runs on head, 0 red, 0 in progress — every row read, not sampled. Lint & Repo Gates, TypeScript Type Check, Test Core rollup, Dogfood Regression Gate, Build Core, Temporal Conformance, Governed Surface Queue Guard all success; three skipped are path-filtered or opt-in.

The route is the ruled one, and the excluded one stayed excluded

The dispatch fenced this card to default role to 'member', aligned with invitations.resend, and ⛔ excluded "declare role required" — that would narrow a published request type rather than fix the call. Delivered as fenced: role stays optional, no published type moves, and an explicitly named role (including an explicit undefined, treated as omission) still reaches the wire unchanged.

⭐ Both stop conditions were hit, and both were discharged rather than waved past

The dispatch attached two conditions that would have stopped delivery. Both fired, and neither was decided quietly:

  1. Caller census with a firing control — a live two-argument caller would have meant stop and re-grade to p2. Measured: no live caller of the two-argument form, in-repo or in Console. ⇒ priority:p3 stands and ⛔ no re-grade is owed. That is a reading, not an assumption, and it is the reason this card did not bounce back to triage.
  2. Implicit role assignment — if judged unacceptable, the instruction was to stop and let the PM convert this to a decision. The seat judged 'member' acceptable and ⛔ did not switch routes, on three grounds it stated rather than asserted: it is the floor of the closed ADR-0108 D1 vocabulary (orgRoleGrade starts at GRADE_MEMBER and rises only for owner/admin); the invitation lands merely pending, so nothing is conferred until the invitee accepts; and invitations.resend has always substituted exactly 'member' over the same vendor endpoint, in the same file — the standing precedent, not a new policy.

⚠️ Recorded because a reader a year from now should be able to see that "default to member" was a judgement someone made against a named objection, not a default nobody thought about.

Evidence

Driven before and after against a real AuthManager (better-auth 1.7.2 verified at runtime, organization plugin, teams enabled) over a real driver, through the real client:

reading
BEFORE POST /organization/invite-member400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"}
AFTER 200 {"role":"member","status":"pending"}
controls, both runs explicit role → 200; invitations.resend → 200 — unmoved

Ablation direction written down before any leg ran — "RED, 4 failed / 2 passed, failing exactly (1)(3)(4a)(4c)" — and observed exactly that. Prediction held; nothing was amended after the fact. The committed suite re-drives it and adds request-byte pins at full-string equality, plus a compile-time pin that reddens if role is ever declared required.

--pair 17270 exit 0. Package suite 41 files / 492 tests. Whole-repo eslint over 6464 files, 0 errors, 0 warnings.

Residue

#17274 filedinvitations.resend declares teamId and never forwards it, and invite has no teamId member at all, so resending a team invitation silently drops the team placement, with teams enabled by default. The delivering seat measured it and ⛔ could not file it: its duplicate search needed the MCP semantic channel and that call returned API rate limit already exceeded, so rather than file blind it handed the finding over. The duplicate search was run from the PM seat before filing.

Noted, not filed: invitations.resend still spells its own role ?? 'member', redundant once this lands. Behaviour-neutral polish on a measured hard-serial file; successor is the next PR editing that method.

⚠️ One conflict the seat raised rather than resolved silently, and it was mine

It flagged that the harness-injected PR-body attribution block disagrees with AGENTS.md, and followed AGENTS.md. That is correct, and the check is in AGENTS.md's own text: for a PR body the session-URL footer is the form, and for an issue comment the platform appends the bare block and recognises only that shape — sending the harness form would have produced the two-footer state AGENTS.md names as the defect. The contradiction reached this seat through the dispatch brief, which is where it will be fixed.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants